from elixir import *
from sqlalchemy import UniqueConstraint

metadata.bind = "postgres://kjkuan:abc123@localhost:5432/jobhub"
metadata.bind.echo = True


import datetime
import calendar
import utils
from core import ICommand



class UserGroup(Entity):

    """An User may be added into one or more UserGroups.
    
    UserGroups are hierachical. An UserGroup is identified by its path, which
    tells its position within the tree of UserGroups.
    
    For example, '/' designates the root UserGroup while '/group1/group2'
    designates another UserGroup that is a child of '/group1', which is in turn
    a child of '/'.

    The primary purpose of UserGroups is so that User access to different parts
    of the system can be asigned to groups of users, and any access that's
    given to a group is also given to its sub groups.
    
    For example, we will be able to create a rule in the system that says:

        "All users in '/group1' can execute the Command, 
         'jobhub.commands.TestCommand'".

    and then all users in group1 and group2 will be able to execute the
    Jobs for the TestCommand, given there's a '/group1/group2' UserGroup.

    See XXX for access control.
    """

    path = Field(Unicode(64), primary_key=True)
    description = Field(Unicode(1024))
    users = ManyToMany('User')

    def __repr__(self):
        return '<UserGroup path="%s" size="%d">' % \
                    (self.path, len(self.users))


class CommandGroup(Entity):

    """A group for Commands.

    A Command may be categorized into one or more CommandGroups.

    Similar to UserGroup, each CommandGroup is identified by its path.
    For examples, '/' is the root CommandGroup while '/group1/group2/group3'
    is another group that is a child of '/group1/group2'.

    Commands are put into groups, and groups into trees, so that groups of
    Commnads can be identified with path patterns, and used in access
    control rules. For example, this rule:

        command /group1/*/ABC/* /staff execute

    says that any user under the '/staff' UserGroup, and the groups below it,
    is allowed to execute any Jobs for the Commands that are directly under
    the CommandGroups whose pathes match the pattern 'group1/.../ABC'. Such
    groups might include, '/group1/X/ABC', '/group1/X/Y/ABC' or '/group1/ABC',
    for example.

    See XXX for access control.
    """

    path = Field(Unicode(512), primary_key=True)
    description = Field(Unicode(1024))
    commands = ManyToMany('Command')

    def __repr__(self):
        return '<CommandGroup path="%s" size="%d">' % \
                    (self.path, len(self.commands))


class User(Entity):

    """An User of the job system.

    Each User must belongs to at least one UserGroup; by default,
    an user belongs to root UserGroup, '/'.
    """

    id = Field(Unicode(32), primary_key=True)     # user login id
    password = Field(String(128), required=True)
    name = Field(Unicode(64), required=True)   # defaults to id
    email = Field(Unicode(128), required=True)
    timezone = Field(String(3), required=True, default='UTC')
    # the 3-char timezone code

    jobs = OneToMany('Job')     # Jobs created by the User.
    runs = OneToMany('JobRun')  # JobRuns runned by the User.
    groups = ManyToMany('UserGroup')
             # defaults to UserGroup.query.filter(path='/')

    def __repr__(self):
        return '<User id="%s" name="%s" email="%s">' % (
            self.id, self.name, self.email)


class Command(Entity):

    """A Command does a job. What it does and how it does it depends on
    its implementation.

    Each Command must belong to at least one CommandGroup; by default
    a Command belongs to the root CommandGroup, '/'.
    """

    class_name = Field(Unicode(256), primary_key=True)
    # fully qualified python class name that implements jobhub.core.ICommand

    name = Field(Unicode(64), required=True) # defaults to class_name
    description = Field(Unicode(1024))
    multiplex = Field(Integer, default=1024)
    # how many Jobs of this Command is allowed to run at one time.

    jobs = OneToMany('Job')   # Jobs created for the Command
    groups = ManyToMany('CommandGroup')
             # defaults to CommandGroup.query.filter(path='/')

    def __repr__(self):
        return '<Command name="%s" class="%s">' % (self.name, self.class_name)

    def new_instance(self):
        """Return an ICommand object for running the command."""
        clsname = self.class_name
        i = clsname.rfind('.')
        if i < 0:
            raise ValueError(
                "Command.class_name: %s must be fully qualified!" % clsname)
        modname = clsname[:i]
        mod = utils.import_module(modname)
        cls = getattr(mod, clsname[i+1:])
        if not issubclass(cls, ICommand):
            raise ValueError(
                "Command.class_name: %s must implements %s!" % (
                    clsname, ICommand.__module__ + '.' + ICommand.__name__))
        return cls()


class Job(Entity):

    """A Job is a configuration of a Command.

    It provides the arguments and the setting in which the Command for it
    may be run.
   
    A Job, once persisted, should not be modified. This includes changes(
    modifications/addtions/deletions) to existing EnvironVar and JobArgument
    objects of a Job.

    However, additions or removals of JobRun or Schedule to a Job is fine.
    Changing an existing Schedule is fine too.

    A Job can be deleted. When deleting a Job, All parts that belongs to the
    Job must also be deleted. This includes, Schedule, EnvironVar,
    JobArgument, JobRun and JobNotRun.
    """

    user = Field(Unicode(32))  # OS user to run this job; None if don't care.
    host = Field(String(128))     # host to run this job on; None if don't care.
    work_dir = Field(Unicode(512))
    # work dir to run this job from, None if don't care.

    creator = ManyToOne('User', required=True)
    creation_time = Field(DateTime)
    remarks = Field(Unicode(1024), required=True)

    command = ManyToOne('Command', required=True)
    args = OneToMany('JobArgument')
    vars = OneToMany('EnvironVar', collection_class=dict)


    runs = OneToMany('JobRun')
    schedules = OneToMany('Schedule')

    def __repr__(self):
        return '<Job cmd="%s" by="%s">' % (self.command.name, self.creator.id)

    def add_arg(self, value, name=None):
        """Add a JobArgument to this job.

          - value: string
          - name: string

        If argument name is ommited then it will be default to len(self.args).
        """
        if not name:
            name = len(self.args)
        self.args.append(JobArgument(self, name, value))

    def get_args(self):
        """Return all JobArguments in a dictionary.

        If a JobArgument name has several values then the values will be
        collected in a list.
        """
        d = {}
        for arg in self.args:
            name = arg.name
            if name in d:
                d[name] = [d[name]]
                d.append(arg.value)
            else:
                d[name] = arg.value
        return d

    def get_positionals(self):
        """Return a list containing only the JobArguments whose name is an
        integer; sorted.
        """
        args = []
        for name, value in self.args:
            try:
                args.append((int(name), value))
            except ValueError:
                continue
        args.sort()
        return args
    
    def add_schedue(self, schedule):
        """Add the schedule for the job."""
        self.schedules.append(schedules)


class JobArgument(Entity):

    """A name-value pair that represent an argument to a Command.

    The pair must be unique among all the arguments of a single Job.
    A name can have one or more values.

    JobArguments are named to make presenting and configuring the arguments
    to a Command easier. Indexed/positional arguments can be emulated by
    names that are integers. See also Job.add_arg() and Job.get_positionals().
    """

    job = ManyToOne('Job', primary_key=True)
    name = Field(Unicode(32), primary_key=True)
    value = Field(Unicode(256), primary_key=True)
    description = Field(Unicode(1024))

    def __init__(job, name, value):
        self.job = job
        self.name = name
        self.value = value

    def __repr__(self):
        return '<JobArgument name="%s" value="%s">' % (self.name, self.value)


class EnvironVar(Entity):

    """An environment variable(name-value pair)for Job.

    All environment variable names for a Job must be unique.

    These represent additional environment variables that will set for the
    execution of the Command.
    """

    name = Field(String(32), primary_key=True)
    value = Field(Unicode(256))
    job = ManyToOne('Job', required=True)
    description = Field(Unicode(1024))

    def __repr__(self):
        return '<EnvironVar name="%s" value="%s">' % (self.name, self.value)


class JobRun(Entity):

    """A JobRun is an execution record of a Job by its Command.

    It keeps track of common attributes of a running Job, such as start_time,
    end_time, and execution status(successful/failed/...etc).

    Outputs(stdout and stderr) and artifacts of a Job and are managed and kept
    by the JobIOManager.
    
    A JobRun, once completed, should not be modified. This includes its
    associated files.
    """

    owner = ManyToOne('User', required=True)
    pid = Field(Integer) # the process id
    # if the Command calls an external subprocess to do its job then it's
    # the process id of that external process; otherwise it's None.

    start_time = Field(DateTime, required=True, default=datetime.datetime.now)
    end_time = Field(DateTime)
    status = Field(Integer)
    class Status(object):
        RUNNING = None
        SUCCESSFUL = 0
        FAILED = 1
        KILLED = -1

    owned_group = ManyToOne('JobRunGroup')     # the JobRunGroup owned
    group = ManyToOne('JobRunGroup')  # the JobRunGroup this JobRun is part of

    job = ManyToOne('Job', required=True)
    runner = ManyToOne('JobRunner', required=True)
    artifacts = OneToMany('Artifact')

    def __repr__(self):
        return '<JobRun pid="%s" start_time="%s" status="%s">' % (
            self.pid, self.start_time, self.status)


class JobRunGroup(Entity):
    """A JobRunGroup provides a way to group a set of related JobRun's.
    The grouped JobRun's need not come from the same Job.
    """

    owner = OneToOne('JobRun', inverse='owned_group')
    runs = OneToMany('JobRun', inverse='group')
    # Note: must have at least one JobRun per group


class Artifact(Entity):

    """Artifacts are relevant files created as a result of running the
    Command of a Job. They are the products of a Job. 
    """

    package = Field(Unicode(512), primary_key=True)
    name = Field(Unicode(64), primary_key=True)
    mime_type = Field(String(32))
    version = Field(Unicode(64))
    checksum = Field(String(1024), required=True)

    jobrun = ManyToOne('JobRun')


class JobNotRun(Entity):

    start_time = Field(DateTime, required=True)
    reason = Field(Integer)
    job = ManyToOne('Job', required=True)

    def __repr__(self):
        return '<JobNotRun start_time="%s">' % self.start_time


class Schedule(Entity):

    """A Schedule tells when or how often to run a Job.

    The datetime stored is assumed to be in UTC timezone.

    The Schedules of a single Job are not allowed to overlap.
    Past schedules that won't happen again may be removed by the system.
    """

    job = ManyToOne('Job', required=True)

    year = Field(Integer)          # > 0
    month = Field(Integer)         # 1 .. 12
    day_of_month = Field(Integer)  # 1 .. 31
    day_of_week = Field(Integer)   # 0 .. 6     0=Sunday
    hour = Field(Integer)          # 0 .. 23
    minute = Field(Integer)        # 0 .. 59
    is_enabled = Field(Boolean)

    # value, None, matches any valid value for the field.
    
    calendar = calendar.Calendar() # first day of week is Monday

    def __init__(self, year, month, day_of_month, day_of_week, hour, minute):
        self.year = year
        self.month = month
        self.day_of_month = day_of_month
        self.day_of_week = day_of_week
        self.hour = hour
        self.minute = minute

#        now = datetime.datetime.utcnow()
#        raise ValueError("Can't create a passed schedule!")


    def is_absolute(self):
        return None not in \
            (self.year, self.month, self.day_of_month, self.hour, self.minute)

    def to_datetime(self):
        return datetime.datetime(
                self.year, self.month, self.day_of_month, hour, minute)


class Service(Entity):

    using_options(
        inheritance='multi', polymorphic='type', identity='Service')

    name = Field(Unicode(50), required=True, primary_key=True)
    host = Field(Unicode(128), required=True)
    port = Field(Integer, required=True)
    is_online = Field(Boolean, default=False)


class JobRunner(Service):

    using_options(
        inheritance='multi', polymorphic='type', identity='JobRunner')

    host = Field(Unicode(128), required=True)
    user = Field(Unicode(30), required=True)
    work_dir = Field(Unicode(256), required=True)
    multiplex = Field(Integer, default=1024)

    using_table_options(UniqueConstraint('host', 'user', 'work_dir'))




